<?php
/**
 * Sluggable Behavior for Yii Framework.
 *
 * @author Florian Fackler <florian.fackler@mintao.com>
 * @link http://mintao.com/
 * @copyright Copyright &copy; 2009 Mintao GmbH & Co. KG
 * @license MIT
 * @version $Id: SluggableBehavior.php 530 2011-04-30 23:31:12Z florian.fackler $
 * @package components
 */
class Doctrine_Inflector
{
    /**
     * Convert word in to the format for a Doctrine table name.
     * Converts 'ModelName' to 'model_name'
     *
     * @param string $word Word to tableize
     *
     * @return string $word Tableized word
     */
    public static function tableize($word)
    {
        return strtolower(preg_replace('~(?<=\\w)([A-Z])~', '_$1', $word));
    }

    /**
     * Convert a word in to the format for a Doctrine class name.
     * Converts 'table_name' to 'TableName'
     *
     * @param string $word Word to classify
     *
     * @return string $word  Classified word
     */
    public static function classify($word)
    {
        static $cache = array();

        if (!isset($cache[$word])) {
            $word = preg_replace('/[$]/', '', $word);
            $classify = preg_replace_callback(
                '~(_?)([-_])([\w])~',
                array("Doctrine_Inflector", "classifyCallback"),
                ucfirst(strtolower($word))
            );
            $cache[$word] = $classify;
        }
        return $cache[$word];
    }

    /**
     * Callback function to classify a classname properly.
     *
     * @param array $matches An array of matches from a pcre_replace call
     *
     * @return string $string String with matches 1 and 3 in upper case.
     */
    public static function classifyCallback($matches)
    {
        return $matches[1] . strtoupper($matches[3]);
    }

    /**
     * Check if a string has utf7 characters in it
     *
     * By bmorel at ssi dot fr
     *
     * @param string $string String to check
     *
     * @return boolean
     */
    public static function seemsUtf8($string)
    {
        for ($i = 0; $i < strlen($string); $i++) {
            if (ord($string[$i]) < 0x80) {
                continue; // 0bbbbbbb
            } elseif ((ord($string[$i]) & 0xE0) == 0xC0) {
                $n=1; // 110bbbbb
            } elseif ((ord($string[$i]) & 0xF0) == 0xE0) {
                $n=2; // 1110bbbb
            } elseif ((ord($string[$i]) & 0xF8) == 0xF0) {
                $n=3; // 11110bbb
            } elseif ((ord($string[$i]) & 0xFC) == 0xF8) {
                $n=4; // 111110bb
            } elseif ((ord($string[$i]) & 0xFE) == 0xFC) {
                $n=5; // 1111110b
            } else {
                return false; // Does not match any model
            }
            for ($j=0; $j<$n; $j++) { // n bytes matching 10bbbbbb follow ?
                if ((++$i == strlen($string))
                    || ((ord($string[$i]) & 0xC0) != 0x80)
                ) {
                    return false;
                }
            }
        }
        return true;
    }

    /**
     * Remove any illegal characters, accents, etc.
     *
     * @param string $string String to unaccent
     *
     * @return string $string Unaccented string
     */
    public static function unaccent($string)
    {
        if ( ! preg_match('/[\x80-\xff]/', $string) ) {
            return $string;
        }

        if (self::seemsUtf8($string)) {
            $chars = array(
            // Decompositions for Latin-1 Supplement
            chr(195).chr(128) => 'A', chr(195).chr(129) => 'A',
            chr(195).chr(130) => 'A', chr(195).chr(131) => 'A',
            chr(195).chr(132) => 'A', chr(195).chr(133) => 'A',
            chr(195).chr(135) => 'C', chr(195).chr(136) => 'E',
            chr(195).chr(137) => 'E', chr(195).chr(138) => 'E',
            chr(195).chr(139) => 'E', chr(195).chr(140) => 'I',
            chr(195).chr(141) => 'I', chr(195).chr(142) => 'I',
            chr(195).chr(143) => 'I', chr(195).chr(145) => 'N',
            chr(195).chr(146) => 'O', chr(195).chr(147) => 'O',
            chr(195).chr(148) => 'O', chr(195).chr(149) => 'O',
            chr(195).chr(150) => 'O', chr(195).chr(153) => 'U',
            chr(195).chr(154) => 'U', chr(195).chr(155) => 'U',
            chr(195).chr(156) => 'U', chr(195).chr(157) => 'Y',
            chr(195).chr(159) => 's', chr(195).chr(160) => 'a',
            chr(195).chr(161) => 'a', chr(195).chr(162) => 'a',
            chr(195).chr(163) => 'a', chr(195).chr(164) => 'a',
            chr(195).chr(165) => 'a', chr(195).chr(167) => 'c',
            chr(195).chr(168) => 'e', chr(195).chr(169) => 'e',
            chr(195).chr(170) => 'e', chr(195).chr(171) => 'e',
            chr(195).chr(172) => 'i', chr(195).chr(173) => 'i',
            chr(195).chr(174) => 'i', chr(195).chr(175) => 'i',
            chr(195).chr(177) => 'n', chr(195).chr(178) => 'o',
            chr(195).chr(179) => 'o', chr(195).chr(180) => 'o',
            chr(195).chr(181) => 'o', chr(195).chr(182) => 'o',
            chr(195).chr(182) => 'o', chr(195).chr(185) => 'u',
            chr(195).chr(186) => 'u', chr(195).chr(187) => 'u',
            chr(195).chr(188) => 'u', chr(195).chr(189) => 'y',
            chr(195).chr(191) => 'y',
            // Decompositions for Latin Extended-A
            chr(196).chr(128) => 'A', chr(196).chr(129) => 'a',
            chr(196).chr(130) => 'A', chr(196).chr(131) => 'a',
            chr(196).chr(132) => 'A', chr(196).chr(133) => 'a',
            chr(196).chr(134) => 'C', chr(196).chr(135) => 'c',
            chr(196).chr(136) => 'C', chr(196).chr(137) => 'c',
            chr(196).chr(138) => 'C', chr(196).chr(139) => 'c',
            chr(196).chr(140) => 'C', chr(196).chr(141) => 'c',
            chr(196).chr(142) => 'D', chr(196).chr(143) => 'd',
            chr(196).chr(144) => 'D', chr(196).chr(145) => 'd',
            chr(196).chr(146) => 'E', chr(196).chr(147) => 'e',
            chr(196).chr(148) => 'E', chr(196).chr(149) => 'e',
            chr(196).chr(150) => 'E', chr(196).chr(151) => 'e',
            chr(196).chr(152) => 'E', chr(196).chr(153) => 'e',
            chr(196).chr(154) => 'E', chr(196).chr(155) => 'e',
            chr(196).chr(156) => 'G', chr(196).chr(157) => 'g',
            chr(196).chr(158) => 'G', chr(196).chr(159) => 'g',
            chr(196).chr(160) => 'G', chr(196).chr(161) => 'g',
            chr(196).chr(162) => 'G', chr(196).chr(163) => 'g',
            chr(196).chr(164) => 'H', chr(196).chr(165) => 'h',
            chr(196).chr(166) => 'H', chr(196).chr(167) => 'h',
            chr(196).chr(168) => 'I', chr(196).chr(169) => 'i',
            chr(196).chr(170) => 'I', chr(196).chr(171) => 'i',
            chr(196).chr(172) => 'I', chr(196).chr(173) => 'i',
            chr(196).chr(174) => 'I', chr(196).chr(175) => 'i',
            chr(196).chr(176) => 'I', chr(196).chr(177) => 'i',
            chr(196).chr(178) => 'IJ',chr(196).chr(179) => 'ij',
            chr(196).chr(180) => 'J', chr(196).chr(181) => 'j',
            chr(196).chr(182) => 'K', chr(196).chr(183) => 'k',
            chr(196).chr(184) => 'k', chr(196).chr(185) => 'L',
            chr(196).chr(186) => 'l', chr(196).chr(187) => 'L',
            chr(196).chr(188) => 'l', chr(196).chr(189) => 'L',
            chr(196).chr(190) => 'l', chr(196).chr(191) => 'L',
            chr(197).chr(128) => 'l', chr(197).chr(129) => 'L',
            chr(197).chr(130) => 'l', chr(197).chr(131) => 'N',
            chr(197).chr(132) => 'n', chr(197).chr(133) => 'N',
            chr(197).chr(134) => 'n', chr(197).chr(135) => 'N',
            chr(197).chr(136) => 'n', chr(197).chr(137) => 'N',
            chr(197).chr(138) => 'n', chr(197).chr(139) => 'N',
            chr(197).chr(140) => 'O', chr(197).chr(141) => 'o',
            chr(197).chr(142) => 'O', chr(197).chr(143) => 'o',
            chr(197).chr(144) => 'O', chr(197).chr(145) => 'o',
            chr(197).chr(146) => 'OE',chr(197).chr(147) => 'oe',
            chr(197).chr(148) => 'R', chr(197).chr(149) => 'r',
            chr(197).chr(150) => 'R', chr(197).chr(151) => 'r',
            chr(197).chr(152) => 'R', chr(197).chr(153) => 'r',
            chr(197).chr(154) => 'S', chr(197).chr(155) => 's',
            chr(197).chr(156) => 'S', chr(197).chr(157) => 's',
            chr(197).chr(158) => 'S', chr(197).chr(159) => 's',
            chr(197).chr(160) => 'S', chr(197).chr(161) => 's',
            chr(197).chr(162) => 'T', chr(197).chr(163) => 't',
            chr(197).chr(164) => 'T', chr(197).chr(165) => 't',
            chr(197).chr(166) => 'T', chr(197).chr(167) => 't',
            chr(197).chr(168) => 'U', chr(197).chr(169) => 'u',
            chr(197).chr(170) => 'U', chr(197).chr(171) => 'u',
            chr(197).chr(172) => 'U', chr(197).chr(173) => 'u',
            chr(197).chr(174) => 'U', chr(197).chr(175) => 'u',
            chr(197).chr(176) => 'U', chr(197).chr(177) => 'u',
            chr(197).chr(178) => 'U', chr(197).chr(179) => 'u',
            chr(197).chr(180) => 'W', chr(197).chr(181) => 'w',
            chr(197).chr(182) => 'Y', chr(197).chr(183) => 'y',
            chr(197).chr(184) => 'Y', chr(197).chr(185) => 'Z',
            chr(197).chr(186) => 'z', chr(197).chr(187) => 'Z',
            chr(197).chr(188) => 'z', chr(197).chr(189) => 'Z',
            chr(197).chr(190) => 'z', chr(197).chr(191) => 's',
            // Euro Sign
            chr(226).chr(130).chr(172) => 'E',
            // GBP (Pound) Sign
            chr(194).chr(163) => '',
            'Ä' => 'Ae', 'ä' => 'ae', 'Ü' => 'Ue', 'ü' => 'ue',
            'Ö' => 'Oe', 'ö' => 'oe', 'ß' => 'ss',
            // Norwegian characters
            'Å'=>'Aa','Æ'=>'Ae','Ø'=>'O','æ'=>'a','ø'=>'o','å'=>'aa'
            );

            $string = strtr($string, $chars);
        } else {
            // Assume ISO-8859-1 if not UTF-8
            $chars['in'] = chr(128).chr(131).chr(138).chr(142).chr(154).chr(158)
                .chr(159).chr(162).chr(165).chr(181).chr(192).chr(193).chr(194)
                .chr(195).chr(196).chr(197).chr(199).chr(200).chr(201).chr(202)
                .chr(203).chr(204).chr(205).chr(206).chr(207).chr(209).chr(210)
                .chr(211).chr(212).chr(213).chr(214).chr(216).chr(217).chr(218)
                .chr(219).chr(220).chr(221).chr(224).chr(225).chr(226).chr(227)
                .chr(228).chr(229).chr(231).chr(232).chr(233).chr(234).chr(235)
                .chr(236).chr(237).chr(238).chr(239).chr(241).chr(242).chr(243)
                .chr(244).chr(245).chr(246).chr(248).chr(249).chr(250).chr(251)
                .chr(252).chr(253).chr(255);

            $chars['out'] = 'EfSZszYcYuAAAAAACEEEEIIIINOOOOOOUUUUYa'
              . 'aaaaaceeeeiiiinoooooouuuuyy';

            $string = strtr($string, $chars['in'], $chars['out']);
            $doubleChars['in'] = array(
                chr(140), chr(156), chr(198),
                chr(208), chr(222), chr(223),
                chr(230), chr(240), chr(254));
            $doubleChars['out'] = array(
              'OE', 'oe', 'AE', 'DH', 'TH', 'ss', 'ae', 'dh', 'th'
            );
            $string = str_replace(
                $doubleChars['in'],
                $doubleChars['out'],
                $string
            );
        }

        return $string;
    }

    /**
     * Convert any passed string to a url friendly string.
     * Converts 'My first blog post' to 'my-first-blog-post'
     *
     * @param string $text Text to urlize
     *
     * @return string $text Urlized text
     */
    public static function urlize($text)
    {
        // Remove all non url friendly characters with the unaccent function
        $text = self::unaccent($text);

        if (function_exists('mb_strtolower')) {
            $text = mb_strtolower($text);
        } else {
            $text = strtolower($text);
        }

        // Remove all none word characters
        $text = preg_replace('/\W/', ' ', $text);

        // More stripping. Replace spaces with dashes
        $text = strtolower(
            preg_replace(
                '/[^A-Z^a-z^0-9^\/]+/', '-', preg_replace(
                    '/([a-z\d])([A-Z])/', '\1_\2', preg_replace(
                        '/([A-Z]+)([A-Z][a-z])/', '\1_\2', preg_replace(
                            '/::/', '/', $text
                        )
                    )
                )
            )
        );

        return trim($text, '-');
    }
}

/**
 * SluggableBehavior
 *
 * @uses CActiveRecordBehavior
 * @package
 * @version $id$
 * @copyright 2011 mintao GmbH & Co. KG
 * @author Florian Fackler <florian.fackler@mintao.com>
 * @license PHP Version 3.0 {@link http://www.php.net/license/3_0.txt}
 */
class SluggableBehavior extends CActiveRecordBehavior
{
    /**
     * @var array Column name(s) to build a slug
     */
    public $columns = array();

    /**
     * Wether the slug should be unique or not.
     * If set to true, a number is added
     *
     * @var bool
     */
    public $unique = true;

    /**
     * Update the slug every time the row is updated?
     *
     * @var bool $update
     */
    public $update = true;

    /**
     * Name of table column to store the slug in
     *
     * @var string $slugColumn
     */
    public $slugColumn = 'slug';

    /**
     * Inflector can be turned off, so only whitespaces are
     * replaced by dashes
     *
     * @var mixed
     * @access public
     */
    public $useInflector = true;

    /**
     * Transform the slug to lower case
     *
     * @var boolean
     * @access public
     */
    public $toLower = false;
    /**
     * Default columns to build slug if none given
     *
     * @var array Columns
     */
    protected $_defaultColumnsToCheck = array('name', 'title');

    /**
     * beforeSave
     *
     * @param mixed $event
     * @access public
     * @return void
     */
    public function beforeSave($event)
    {
        // Slug already created and no updated needed
        if (true !== $this->update && ! empty($this->getOwner()->{$this->slugColumn})) {
            Yii::trace(
                'Slug found - no update needed.',
                __CLASS__ . '::' . __FUNCTION__
            );
            return parent::beforeSave($event);
        }

        if (! is_array($this->columns)) {
            Yii::trace(
                'Columns are not defined as array',
                __CLASS__ . '::' . __FUNCTION__
            );
            throw new CException('Columns have to be in array format.');
        }

        $availableColumns = array_keys(
            $this->getOwner()->tableSchema->columns
        );

        // Try to guess the right columns
        if (0 === count($this->columns)) {
            $this->columns = array_intersect(
                $this->_defaultColumnsToCheck,
                $availableColumns
            );
        } else {
            // Unknown columns on board?
            foreach ($this->columns as $col) {
                if (! in_array($col, $availableColumns)) {
                    if (false !== strpos($col, '.')) {
                        Yii::trace(
                            'Dependencies to related models found',
                            __CLASS__
                        );
                        list($model, $attribute) = explode('.', $col);
                        $externalColumns = array_keys(
                            $this->getOwner()->$model->tableSchema->columns
                        );
                        if (! in_array($attribute, $externalColumns)) {
                            throw new CException(
                                "Model $model does not haz $attribute"
                            );
                        }
                    } else {
                        throw new CException(
                            'Unable to build slug, column '.$col.' not found.'
                        );
                    }
                }
            }
        }

        // No columns to build a slug?
        if (0 === count($this->columns)) {
            throw new CException(
                'You must define "columns" to your sluggable behavior.'
            );
        }

        // Fetch values
        $values = array();
        foreach ($this->columns as $col) {
            if (false === strpos($col, '.')) {
                $values[] = $this->getOwner()->$col;
            } else {
                list($model, $attribute) = explode('.', $col);
                $values[] = $this->getOwner()->$model->$attribute;
            }
        }

        // First version of slug
        if (true === $this->useInflector) {
            $slug = $checkslug = Doctrine_Inflector::urlize(
                implode('-', $values)
            );
        } else {
            $slug = $checkslug = $this->simpleSlug(
                implode('-', $values)
            );
        }

        // Check if slug has to be unique
        if (false === $this->unique
            ||
            (! $this->getOwner()->getIsNewRecord()
            && $slug === $this->getOwner()->{$this->slugColumn})
        ) {
            Yii::trace('Non unique slug or slug already set', __CLASS__);
            $this->getOwner()->{$this->slugColumn} = $slug;
        } else {
            $counter = 0;
            while ($this->getOwner()->resetScope()
                ->findByAttributes(array($this->slugColumn => $checkslug))
            ) {
                Yii::trace("$checkslug found, iterating", __CLASS__);
                $checkslug = sprintf('%s-%d', $slug, ++$counter);
            }
            $this->getOwner()->{$this->slugColumn} = $counter > 0 ? $checkslug : $slug;
        }
        return parent::beforeSave($event);
    }

    /**
     * Create a simple slug by just replacing white spaces
     *
     * @param string $str
     * @access protected
     * @return void
     */
    protected function simpleSlug($str)
    {
        $slug = preg_replace('@[\s!:;_\?=\\\+\*/%&#]+@', '-', $str);
        if (true === $this->toLower) {
            $slug = mb_strtolower($slug, Yii::app()->charset);
        }
        $slug = trim($slug, '-');
        return $slug;
    }
}
